Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pkg/command: wrap jsonmessage.DisplayJSONMessagesStream with go context #5663

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

Benehiko
Copy link
Member

@Benehiko Benehiko commented Dec 2, 2024

Follow-up to #5639

Allows for the jsonmessage.DisplayJSONMessagesStream to
correctly return when the context is cancelled with the
appropriate error message, instead of just a nil error.

Follow-up to 30a73ff

- What I did

- How I did it

- How to verify it

- Description for the changelog

- A picture of a cute animal (not mandatory but encouraged)

@Benehiko Benehiko force-pushed the ctx-jsonmessage branch 2 times, most recently from c21438f to 4884112 Compare December 2, 2024 14:42
@Benehiko Benehiko changed the title pkg/command: pass context along to DisplayJSONMessagesStream pkg/command: wrap DisplayJSONMessagesToStream and DisplayJSONMessagesStream Jan 13, 2025
@codecov-commenter
Copy link

codecov-commenter commented Jan 13, 2025

Codecov Report

Attention: Patch coverage is 24.00000% with 38 lines in your changes missing coverage. Please review.

Project coverage is 59.44%. Comparing base (b462778) to head (8eca7c0).
Report is 21 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #5663      +/-   ##
==========================================
- Coverage   59.51%   59.44%   -0.08%     
==========================================
  Files         346      347       +1     
  Lines       29376    29394      +18     
==========================================
- Hits        17483    17472      -11     
- Misses      10923    10950      +27     
- Partials      970      972       +2     

@Benehiko Benehiko force-pushed the ctx-jsonmessage branch 3 times, most recently from d55ee6d to bfd3131 Compare January 13, 2025 15:30
@Benehiko Benehiko requested review from thaJeztah and vvoland January 15, 2025 12:50
cli/command/image/build.go Outdated Show resolved Hide resolved
Comment on lines 16 to 32
// DisplayStream prints the JSON messages from the given reader to the given stream.
//
// It wraps the [jsonmessage.DisplayJSONMessagesToStream] function to make it
// "context aware" and appropriately returns why the function was canceled.
//
// It returns an error if the context is canceled, but not if the input reader / stream is closed.
func DisplayStream(ctx context.Context, in io.Reader, stream Stream, auxCallback func(JSONMessage)) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
err := jsonmessage.DisplayJSONMessagesToStream(in, stream, auxCallback)
if err != nil {
return err
}
if ctx.Done() != nil {
return ctx.Err()
}
}
return nil
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could drop the select to simplify this a bit

Suggested change
// DisplayStream prints the JSON messages from the given reader to the given stream.
//
// It wraps the [jsonmessage.DisplayJSONMessagesToStream] function to make it
// "context aware" and appropriately returns why the function was canceled.
//
// It returns an error if the context is canceled, but not if the input reader / stream is closed.
func DisplayStream(ctx context.Context, in io.Reader, stream Stream, auxCallback func(JSONMessage)) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
err := jsonmessage.DisplayJSONMessagesToStream(in, stream, auxCallback)
if err != nil {
return err
}
if ctx.Done() != nil {
return ctx.Err()
}
}
return nil
}
// DisplayStream prints the JSON messages from the given reader to the given stream.
//
// It wraps the [jsonmessage.DisplayJSONMessagesToStream] function to make it
// "context aware" and appropriately returns why the function was canceled.
//
// It returns an error if the context is canceled, but not if the input reader / stream is closed.
func DisplayStream(ctx context.Context, in io.Reader, stream Stream, auxCallback func(JSONMessage)) error {
if ctx.Err() != nil {
return ctx.Err()
}
if err := jsonmessage.DisplayJSONMessagesToStream(in, stream, auxCallback); err != nil {
return err
}
return ctx.Err()
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also mostly support the context cancellation by wrapping the in io.Reader we pass into DisplayJSONMessagesToStream with something like:

type stopableReader struct {
    r io.Reader
    closed error
}

func (c *stopableReader) Read(p []byte) (int, error) {
  if (c.closed != nil) {
    return 0, c.closed
  }
  return c.r.Read(p)
}

and then set closed = ctx.Err() after the context is done.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think i'd prefer we be explicit about it instead of in the io.Reader. I'll remove the select

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The io.Reader wrapper is a separate thing though.

With the current implementation the context cancellation isn't actually handled once the DisplayJSONMessagesToStream is called, the cancellation won't have any actual effect.

I pushed a small test to your branch to illustrate that.

The reader wrapper is trying to achieve that, that means this would effectively remove the need to make changes to the pkg/jsonmessage on moby side.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(sorry, late to comment; I saw the discussion, and was indeed wondering if closing the reader would make the jsonmessages properly cancel, as ISTR it was originally designed around some of that)

Copy link
Member Author

@Benehiko Benehiko Jan 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the current implementation the context cancellation isn't actually handled once the DisplayJSONMessagesToStream is called, the cancellation won't have any actual effect.

yes, but the function was never designed for that. This PR is only trying to solve returning a context error instead of just a nil (when the reader is implicitly cancelled by context through say http.NewRequestWithContext).

The reader wrapper is trying to achieve that, that means this would effectively remove the need to make changes to the pkg/jsonmessage on moby side.

This PR's intent is to circumvent the moby side PR - or at least find an alternative without needing to refactor the function.

and was indeed wondering if closing the reader would make the jsonmessages properly cancel

Yes you are correct, it won't. The jsonmessages functions rely on the fact that io.EOF was returned to break the for loop.

EDIT: I meant to say that jsonmessages functions solely rely on io.EOF from the reader to exit, but that doesn't mean we will know why it was cancelled (the io.EOF reason). So closing the reader would exit the loop. https://github.com/moby/moby/blob/master/pkg/jsonmessage/jsonmessage.go#L237-L242

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will also exit on any Read other error and will return it:

https://github.com/moby/moby/blob/1153242d3a6c3635770db65d9233bb892ee1cbc3/pkg/jsonmessage/jsonmessage.go#L237-L243

If the Read inside Decode returns context.Canceled then it would also be returned from Decode.
That's what the stopableReader above does.

This PR's intent is to circumvent the moby side PR - or at least find an alternative without needing to refactor the function.

I get that, but why not make the DisplayStream function actually respect the cancellation if we can?
We control the reader passed so we can make it start returning error for subsequent Reads after the context is done.

With that stopableReader I posted above, we could just wait for ctx.Done() channel to be closed and set r.closed = ctx.Err() to make the reader start returning the context error.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i see, alright I'll look into this then

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I mentioned in #5645 (comment) and #5645 (comment), I don't think adding a context to jsonmessage functions is the best approach – it would be simpler to let those continue only caring about whether the read fails or not to know when to stop instead of having to care about both things.

@vvoland's suggestion allows addressing the issue without needless changes to jsonmessage.

…ontext

Allows for the `jsonmessage.DisplayJSONMessagesToStream` function
to correctly return when the context is cancelled with the appropriate
reason (`ctx.Error()`) instead of just a nil error.

Follow-up to docker@30a73ff

Signed-off-by: Alano Terblanche <[email protected]>
@Benehiko Benehiko changed the title pkg/command: wrap DisplayJSONMessagesToStream and DisplayJSONMessagesStream pkg/command: wrap jsonmessage.DisplayJSONMessagesStream with go context Jan 16, 2025
@Benehiko Benehiko requested a review from vvoland January 16, 2025 10:06
// "context aware" and appropriately returns why the function was canceled.
//
// It returns an error if the context is canceled, but not if the input reader / stream is closed.
func DisplayStream(ctx context.Context, in io.Reader, stream Stream, auxCallback func(JSONMessage)) error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One other thing consider is to add 2 separate functions; one that takes an auxCallBack and one without; looking at the CLI code, I see that we need the auxCallBack in only 2 places;

cli/command/container/create.go:        return jsonmessage.DisplayJSONMessagesToStream(responseBody, out, nil)
cli/command/image/import.go:    return jsonmessage.DisplayJSONMessagesToStream(responseBody, dockerCli.Out(), nil)
cli/command/image/load.go:              return jsonmessage.DisplayJSONMessagesToStream(response.Body, dockerCli.Out(), nil)
cli/command/image/push.go:              err = jsonmessage.DisplayJSONMessagesToStream(responseBody, streams.NewOut(io.Discard), handleAux(dockerCli))
cli/command/image/push.go:      return jsonmessage.DisplayJSONMessagesToStream(responseBody, dockerCli.Out(), handleAux(dockerCli))
cli/command/image/trust.go:             if err := jsonmessage.DisplayJSONMessagesToStream(in, ioStreams.Out(), nil); err != nil {
cli/command/image/trust.go:     if err := jsonmessage.DisplayJSONMessagesToStream(in, ioStreams.Out(), handleTarget); err != nil {
cli/command/image/trust.go:     return jsonmessage.DisplayJSONMessagesToStream(responseBody, out, nil)
cli/command/plugin/install.go:  if err := jsonmessage.DisplayJSONMessagesToStream(responseBody, dockerCli.Out(), nil); err != nil {
cli/command/plugin/push.go:     return jsonmessage.DisplayJSONMessagesToStream(responseBody, dockerCli.Out(), nil)
cli/command/plugin/upgrade.go:  if err := jsonmessage.DisplayJSONMessagesToStream(responseBody, dockerCli.Out(), nil); err != nil {
cli/command/service/helpers.go: err := jsonmessage.DisplayJSONMessagesToStream(pipeReader, dockerCli.Out(), nil)
cli/command/swarm/ca.go:        err := jsonmessage.DisplayJSONMessagesToStream(pipeReader, dockerCli.Out(), nil)

Alternatively, we could consider (for a expandable UX) having it accept a DisplayOptions{} struct (or fancy functional DisplayOptions()) , e.g.

err := DisplayWithOptions(ctx, in, stream, Options{
    AuxCallback: myCallback,
})

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another is to make Display a variadic function like so:

type Options func(*options)

type options struct {
	AuxCallback func(JSONMessage)
}

func WithAuxCallback(cb func(JSONMessage)) Options {
	return func(o *options) {
		o.AuxCallback = cb
	}
}

func Display(ctx context.Context, in io.Reader, stream Stream, opts ...Options) error {
}

Then when you use it, you don't even need to specify the last parameter in most cases.

err := jsonstream.Display(ctx, reader, out)
// or
err := jsonstream.Display(ctx, reader, out, jsonstream.WithAuxCallback(func(j jsonstream.JSONMessage){}))

@@ -0,0 +1,32 @@
package jsonmessage
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we could name this package jsonstream it could help disambiguate with the jsonmessage package, and we could name the function Display() as it would be called as jsonstream.Display()

type (
Stream = jsonmessage.Stream
JSONMessage = jsonmessage.JSONMessage
JSONError = jsonmessage.JSONError
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still an internal package, so not too problematic, but it looks like JSONError is literally used in a single place in the CLI. We may want to have a look if we want to keep it as an exported type, or if there's alternatives to reduce the "public" (but right now, still "internal") API;

if jerr, ok := err.(*jsonmessage.JSONError); ok {
// If no error code is set, default to 1
if jerr.Code == 0 {
jerr.Code = 1
}
if options.quiet {
fmt.Fprintf(dockerCli.Err(), "%s%s", progBuff, buildBuff)
}
return cli.StatusError{Status: jerr.Message, StatusCode: jerr.Code}
}

Copy link
Member Author

@Benehiko Benehiko Jan 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea behind this was to keep all the jsonmessage code in a singular place inside an internal package inside the CLI. Maybe i'm being a bit excessive, but it felt cleaner

This patch returns the `context.Error()` from the `io.Reader`,
allowing us to also immediately cancel the wrapped function
without waiting for an `io.EOF`.

Signed-off-by: Alano Terblanche <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants